連結(Link)在網站開發中是不可或缺的存在。為了讓我們更好的實現 SPA(Single Page Application)網站,我們通常會使用 Vue Router 作為路由管理的解決方案。
既然 Vue Router 中已經有了 <RouterLink>
元件,為什麼我們還需要自己實作 <AtomicLink>
呢?
在大多數使用情境下,<RouterLink>
已經足夠好用,但有時候還是會遇到一些使用上的限制。
<RouterLink to="https://mini-ghost"> Blog </RouterLink>
我們希望點擊這個連結可以導向我的 Blog,但是熟悉 Vue Router 的人一定一眼就發現這裡有個問題,<RouterLink>
並不支援像這樣的外部連結應用。
這裡渲染出來的結果會是這樣:
<a href="/https://mini-ghost.dev/"> Blog </a>
為了解決這個問題,我們可以實作 <AtomicLink>
,讓不論是內部連結還是外部路徑,都能夠按照我們預期的方式呈現。
解決以上問題,並結合自身經驗,我們統整出 <AtomicLink>
的功能:
to
並支援外部連結。target
並且不等於 _self
,則視為外部連結。external
強制視為外部連結。首先,我們將需求中提到的功能整理成 props
的介面,我們會需要下列屬性:
屬性 | 型別 | 預設值 | 說明 |
---|---|---|---|
to | RouteLocationRaw |
'' |
路由目標 |
target | string |
undefined |
連結目標 |
external | boolean |
false |
是否為外部連結 |
interface AtomicLinkProps {
to?: RouteLocationRaw
target?: string
external?: boolean
}
const props = withDefaults(defineProps<AtomicLinkProps>(), {
to: '',
target: undefined,
});
因為 Vue Router 已經提供了 <RouterLink>
元件,所以我們不需要從頭實作,在這裡我們基於 <RouterLink>
進行擴充。
擴充的思路很簡單:如果開發人員傳入的是內部連結,我們就使用 <RouterLink>
元件;如果是外部連結,則使用 <a>
元素。
首先,我們要判斷 to
是否為外部連結。除了判斷 target
與 external
的設定外,如果 to
是一個字串且開頭是「通訊協議」(例如 https://
),那我們就判定它是一個外部連結,實作如下。
import { hasProtocol } from 'ufo';
import { computed } from 'vue';
const isExternal = computed(() => {
// 明確設定外部屬性
if (props.external) {
return true;
}
// 當有設定 target 且不是 `_self` 時視為外部連結
if (props.target && props.target !== '_self') {
return true;
}
// 當 `to` 是一個路由物件時,它就是一個內部路由
if (typeof props.to === 'object') {
return false;
}
// 當 `to` 是一個空字串或是有協定(如:https://)時視為外部連結
return props.to === '' || hasProtocol(props.to, { acceptRelative: true });
});
是否包含通訊協定的邏輯我們交給 ufo
這個工具來處理,這樣我們可以減少許多邊緣案例的判斷。
接下來,我們要解析出給 <a>
使用的 href
。如果 to
是一個物件,我們就使用 router.resolve
來解析出 href
,否則直接使用 to
作為 href
。
// ...
const router = useRouter();
const href = computed(() => {
return typeof props.to === 'object'
? router.resolve(props.to)?.href ?? null
: props.to;
});
我們依據 isExternal
的值來決定要使用 <RouterLink>
還是 <a>
,並且在 <a>
中加入 rel="noopener noreferrer"
來增加安全性。
<template>
<template v-if="!isExternal">
<RouterLink :to="to">
<slot name="default" />
</RouterLink>
</template>
<template v-else>
<a
:href="href"
rel="noopener noreferrer"
:target="target"
>
<slot name="default" />
</a>
</template>
</template>
這樣就可以將前面的範例換成 <AtomicLink>
試試看:
<AtomicLink to="https://mini-ghost.dev"> Blog </AtomicLink>
此時渲染出來的結果會是:
<a href="https://mini-ghost.dev/" rel="noopener noreferrer"> Blog </a>
這樣就完成了 <AtomicLink>
的實作,這個元件可以讓我們更方便地處理外部連結,並且可以透過 external
這個 prop 來強制視為外部連結。
除了讓元件可以兼容外部連結之外,我們還可以進一步擴充這個元件。例如可以加入 Smart Prefetching 機制來提升使用者體驗。
Smart Prefetching 是當連結是內部連結時,我們可以在連結進入畫面後,預先將連結指向的頁面元件下載下來,這樣使用者在點擊連結切換頁面時就能有更低的延遲體驗。
首先,我們來實作 prefetch 的邏輯。
我們可以從 route.matched
確認是否有可能可以 prefetch 的頁面元件。並且,因為畫面上可能同時出現多個相同的連結需要被 prefetch,我們需要加入一些判斷機制來避免不必要的效能浪費。
async function preloadRouteComponents(
to: RouteLocationRaw,
router: Router & {
_routePreloaded?: Set<string>;
}
): Promise<void> {
const { path, matched } = router.resolve(to);
if (!matched.length) return;
if (!router._routePreloaded) router._routePreloaded = new Set();
if (router._routePreloaded.has(path)) return;
router._routePreloaded.add(path);
// ...
}
我們從 route.matched
中找到所有需要 prefetch 的元件,並執行 prefetch:
async function preloadRouteComponents(
to: RouteLocationRaw,
router: Router & {
_routePreloaded?: Set<string>;
}
): Promise<void> {
const { path, matched } = router.resolve(to);
// ...
const promises: Promise<any>[] = []
const components = matched
.map(component => component.components?.default)
.filter(component => isFunction(component));
for (const component of components) {
const promise = Promise.resolve((component as Function)())
.catch(() => {})
promises.push(promise);
}
await Promise.all(promises);
}
接著,我們使用 IntersectionObserver
來偵測元件是否進入畫面,當元件進入畫面時觸發 prefetch。
const linkRef = ref<HTMLElement>();
let observer: IntersectionObserver | null = null;
onMounted(() => {
observer = new IntersectionObserver(entries => {
for (const entry of entries) {
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0;
if (isVisible) {
observer?.unobserve(entry.target);
observer?.disconnect();
observer = null;
if (isExternal.value) return;
preloadRouteComponents(props.to, router);
}
}
});
observer.observe(linkRef.value!);
});
onBeforeUnmount(() => {
observer?.unobserve(linkRef.value!);
observer?.disconnect();
observer = null;
});
linkRef
表示當前元件的 DOM 元素,我們可用下面的方法取得渲染後的 <a>
元素。
<template>
<template v-if="!isExternal">
<RouterLink
:ref="(instance) => {
linkRef.value = instance?.$el;
}"
:to="to as any"
>
<slot name="default" />
</RouterLink>
</template>
<template v-else>
<!-- 不需要 prefetch -->
</template>
</template>
這樣我們就完成了具備 Smart Prefetching 功能的 <AtomicLink>
元件,當連結進入畫面時,會預先下載連結指向的頁面元件。
不過,連結在大型網站中可能會是一個數量非常龐大的元素。如果每個 <AtomicLink>
內部都建立一個新的 IntersectionObserver
來觀察元素是否進入畫面,這樣會造成效能浪費。
我們可以使用「單例模式」來確保只會建立一個 IntersectionObserver
實例,並透過 Map
來記錄每個元素對應的 callback。這樣一來,當元素進入畫面時,就可以取出 callback 執行它。
我們實作一個 createIntersectionObserver
讓 <AtomicLink>
更容易使用它。
type CallbackFn = () => void;
type ObserveFn = (element: Element, callback: CallbackFn) => () => void;
let cache: { observe: ObserveFn } | undefined;
export default function createIntersectionObserver() {
if (cache) return cache;
const observe: ObserveFn = (element, callback) => {
// ...
};
return (cache = { observe });
}
如果建立過 observe
,就直接回傳,否則建立一個新的 observe
後儲存在 cache
中並回傳。
接著,我們在內部建立一個 IntersectionObserver
實例,並透過 Map
記錄每個元素對應的 callback。
// ...
export default function createIntersectionObserver() {
if (cache) return cache;
let observer: IntersectionObserver | null = null;
const callbacks = new Map<Element, CallbackFn>();
const observe: ObserveFn = (element, callback) => {
if (!observer) {
observer = new IntersectionObserver(entries => {
for (const entry of entries) {
const callback = callbacks.get(entry.target);
const isVisible = entry.isIntersecting || entry.intersectionRatio > 0;
if (isVisible && callback) {
callback();
}
}
});
}
callbacks.set(element, callback);
observer.observe(element);
};
return (cache = { observe });
}
這裡應用了兩層單例模式。第一層的 cache
確保無論呼叫多少次 createIntersectionObserver
建立一個 { observe }
物件並在之後共用;第二層單例模式則確保無論呼叫多少次 observe
都只會有一個 IntersectionObserver
實例。
與元件中的實作不同,我們不會在 element
進入畫面後立即解除觀察。是否解除觀察應該由使用的地方自行負責,這樣才能讓 createIntersectionObserver
更有彈性,不會因應未來需求變化而無法重複使用或需要修改原本的實作。
不過,我們可以在 observe
中回傳一個 unobserve
函式,讓使用者可以在不需要觀察時呼叫此函式來解除觀察。
// ...
export default function createIntersectionObserver() {
if (cache) return cache;
let observer: IntersectionObserver | null = null;
const callbacks = new Map<Element, CallbackFn>();
const observe: ObserveFn = (element, callback) => {
// ...
return () => {
callbacks.delete(element);
observer!.unobserve(element);
if (callbacks.size === 0) {
observer!.disconnect();
observer = null;
}
};
};
return (cache = { observe });
}
我們順便加上了是否有其他元素在使用 IntersectionObserver
的判斷。當 callbacks
被清空,表示目前沒有任何地方在使用,此時我們可以考慮釋放記憶體,直到下次有地方呼叫 observe
時再重新建立。
最後,我們將 createIntersectionObserver
導入 <AtomicLink>
中,並使用它來觀察元素是否進入畫面。
onMounted(() => {
if (!linkRef.value) return;
const { observe } = createIntersectionObserver();
unobserve = observe(linkRef.value, () => {
unobserve?.();
unobserve = null;
if (isExternal.value) return;
preloadRouteComponents(props.to, router);
});
});
onBeforeUnmount(() => {
unobserve?.();
unobserve = null;
});
這樣一來,就算畫面上同時出現了幾百甚至上千個 <AtomicLink>
元件,我們也只會建立一個 IntersectionObserver
實例。這樣不但滿足了 Smart Prefetching 的需求,如果其他元件也需要相同的 IntersectionObserver
,我們也可以使用 createIntersectionObserver
來避免建立多餘的 IntersectionObserver
實例。
在 <AtomicLink>
中,我們基於 <RouterLink>
擴充了兩個功能:讓 to
可以是外部連結,另一個是 Smart Prefetching。這兩個功能可以讓我們更方便地處理外部連結,也讓網站使用者獲得更好的使用體驗。
Smart Prefetch 在這裡採用的機制是當元件進入畫面後觸發 prefetch,但根據需求也可以考慮其他方式,像是使用 mouseover
事件來觸發 prefetch 也是個不錯的選擇。
查看 Vue 主流的 UI Library,例如:Vuetify、Element Plus、PrimeVue 等等,它們都沒有特別實作類似的元件。但如果有使用過 Nuxt 的讀者可能已經發現了,<AtomicLink>
的功能跟 <NuxtLink>
幾乎是一樣的!<AtomicLink>
的實作大量參照了 <NuxtLink>
的原始碼。如果你的專案是使用 Nuxt,那麼直接使用 <NuxtLink>
就能達到完全相同的效果。但如果專案是使用 Vite + Vue,不妨將 <AtomicLink>
導入你的專案中試試看!
Nuxt 的
<NuxtLink>
在 v3.13.0 中已經支援了不同的 Smart Prefetch 機制,有興趣的可以參考看看。
<AtomicLink>
原始碼:AtomicLink.vue
想問把 v-if
和 v-else
掛在 template
上,而不是內層的 RouterLink
和 a
身上是什麼原因呢?
這部分比較是我個人的習慣(怪僻),但綁定在 <RouterLink>
和 <a>
上是完全沒有問題的。
在下列場景我通常會選擇綁定在 <template>
上,像是 <RouterLink>
上還有其他屬性,特別是超過兩個以上,我不太想把元件的 props 與其他 Vue 的語法混在一起。
<template v-if="!isExternal">
<RouterLink
:ref="resolveRef"
:to="to as any"
>
<slot name="default" />
</RouterLink>
</template>
如果是元素只有一個屬性或是沒有屬性,我通常會選擇綁定在元素上。
<span
v-if="prepend || $slots.prepend"
class="atomic-textarea__prepend"
>
<!-- 略 -->
</span>
<span v-if="shouldShowCount">
<!-- 略 -->
</span>
我這樣做的目的僅在追求我自己視覺上的平衡,以及一部分受到其他框架的影響,所以通常會這樣做但也不是 100% 這樣做。
了解~感謝大大解惑!